昨天把心電圖的線畫出來之後,我把瀏覽器開著晾在旁邊,自己去倒了杯水。
回來的時候畫面還亮著,那條綠線還在螢幕上滴滴答答地跑。我盯著它看了幾秒,突然有點恍神:這已經是 Day 21 了。
掐指一算,從 Day 8 開始算起,連續十四天的旅程。按鈕學會了發聲、籤筒學會了搖晃、分數學會了被記住、雷達圖學會了展開、處方籤學會了對症下藥、病歷本學會了留住昨天、電池學會了誠實告訴你還剩幾格、六個維度終於站上了同一把尺、三種音效總算搬進各自的房間、四種語言現在張口就能切換、呼吸的字也不會再露餡、現在連心跳都有了。
每一篇寫完,我幾乎立刻就在想「明天要補哪個洞」,很少真的停下來回頭看一眼走過的路。
今天我就先把這段路攤開來,順便挑幾樣東西拼在同一頁,證明它們擺在一起不會打架。
把這段路重新梳理一次,我發現大致能收進三個抽屜。
第一個抽屜:讓操作被聽見
Day 8 到 Day 10,做的其實是同一類事:手指點下去的那一刻,世界要有反應。按鈕要發出一聲清脆的「啵」、籤筒要有搖晃的儀式感、飄過的壓力字眼要能被一拳打爆。拆到骨子裡,這三件事全靠同一套 osc + gain 的組合,只是調了不同的頻率跟時長。
第二個抽屜:讓資料學會講人話
Day 11 到 Day 17,處理的對象從頭到尾都是「分數」。先用一個物件把六個維度的分數老實收起來(Day 11)、畫成六角形(Day 12)、讓它慢慢展開而不是一次砸出來(Day 13)、根據最弱的那個維度開一張唬爛處方(Day 14)、把病歷存進瀏覽器的小倉庫(Day 15)、算出一句「耗損 34%」取代一排生硬數字(Day 16)、最後把六個維度各自站上同一把尺,不讓題庫給分大方的維度佔便宜(Day 17)。七篇串起來,其實就是一條「原始數字 → 有意義的一句話」的流水線。
第三個抽屜:讓體驗不再有破綻
Day 18 到 Day 21,回頭打磨細節。音效拆成各自獨立的函式(Day 18)、文字學會切換四種語言(Day 19)、連動態產生的呼吸文字也接上語言表(Day 20)、最後補一台會隨進度跳動的心電圖(Day 21),讓整個監測感更完整。
三個抽屜攤開來看,其實共用同一句話,是我在 Day 15 寫過的:
「真正讓我們覺得舒服的,也許不是內容本身,而是那個『我對世界做了一件事,世界立刻有回應』的感覺。」
點下去有聲音、答完題有分數、進度推進有心跳——拆穿了都是同一件事:讓使用者感覺到,系統正在聽他。
今天我只挑三個已經寫過的機制,塞進同一個頁面,看看它們能不能和平共處:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:第二階段拼盤</title>
<style>
body {
font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
background: #0d1117;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
color: #e6edf3;
}
.card {
background: #161b22;
padding: 25px;
border-radius: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.5);
width: 100%;
max-width: 460px;
}
h2 { color: #4ade80; margin: 0 0 4px; text-align: center; }
.subtitle { color: #8b949e; font-size: 0.85rem; text-align: center; margin-bottom: 20px; }
.lang-switch { display: flex; justify-content: center; gap: 8px; margin-bottom: 18px; }
.lang-btn {
background-color: #21262d;
border: 2px solid transparent;
border-radius: 50px;
padding: 6px 14px;
font-size: 0.8rem;
cursor: pointer;
color: #e6edf3;
transition: 0.2s;
}
.lang-btn.active { background-color: #238636; border-color: #238636; }
.block {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 14px;
padding: 14px;
margin-bottom: 14px;
}
.block h3 { margin: 0 0 8px; font-size: 0.95rem; color: #e6edf3; }
.block .tag { font-size: 0.7rem; color: #8b949e; margin-left: 6px; }
.btn {
background-color: #238636;
color: white;
border: none;
border-radius: 50px;
padding: 9px 20px;
font-size: 0.85rem;
cursor: pointer;
transition: 0.2s;
}
.btn:hover { background-color: #2ea043; }
canvas { display: block; width: 100%; height: 70px; margin-top: 8px; }
.progress-track { height: 8px; background: #21262d; border-radius: 4px; overflow: hidden; margin-top: 8px; }
.progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #4ade80, #f87171); transition: width 0.4s ease; }
</style>
</head>
<body>
<div class="card">
<h2>🗂️ 第二階段拼盤</h2>
<p class="subtitle">Day 8 ~ Day 21,挑三樣端上桌</p>
<div class="lang-switch">
<button class="lang-btn" data-lang="zh">中文</button>
<button class="lang-btn" data-lang="en">EN</button>
<button class="lang-btn" data-lang="ja">日本語</button>
<button class="lang-btn" data-lang="ko">한국어</button>
</div>
<div class="block">
<h3 data-i18n="soundTitle">合成音效<span class="tag">Day 8 / 18</span></h3>
<button class="btn" onclick="playClickSound()" data-i18n="clickBtn">聽聽點擊音</button>
<button class="btn" onclick="playDrawSound()" data-i18n="drawBtn">聽聽求籤音</button>
</div>
<div class="block">
<h3 data-i18n="ecgTitle">心跳監測<span class="tag">Day 21</span></h3>
<canvas id="ecg" width="400" height="70"></canvas>
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
<button class="btn" onclick="nextQuestion()" data-i18n="stepBtn" style="margin-top:8px;">模擬作答一題</button>
</div>
</div>
<script>
// ---------- 把三個階段的機制,塞進同一頁 ----------
// Day 8 / 18:共用的合成引擎
let audioCtx = null;
function getAudio() {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === "suspended") audioCtx.resume();
return audioCtx;
}
function playTone({ type, startFreq, endFreq, duration, volume }) {
try {
const ctx = getAudio();
const t = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(startFreq, t);
osc.frequency.exponentialRampToValueAtTime(endFreq, t + duration);
gain.gain.setValueAtTime(volume, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + duration);
} catch (e) {}
}
function playClickSound() {
playTone({ type: "sine", startFreq: 600, endFreq: 200, duration: 0.08, volume: 0.15 });
}
function playDrawSound() {
playTone({ type: "triangle", startFreq: 300, endFreq: 700, duration: 0.3, volume: 0.18 });
}
// Day 19 / 20:語言表
const i18n = {
soundTitle: { zh: "合成音效", en: "Sound Engine", ja: "サウンドエンジン", ko: "사운드 엔진" },
clickBtn: { zh: "聽聽點擊音", en: "Play click sound", ja: "クリック音を再生", ko: "클릭음 재생" },
drawBtn: { zh: "聽聽求籤音", en: "Play draw sound", ja: "おみくじ音を再生", ko: "뽑기음 재생" },
ecgTitle: { zh: "心跳監測", en: "Heartbeat Monitor", ja: "心拍モニター", ko: "심박 모니터" },
stepBtn: { zh: "模擬作答一題", en: "Simulate 1 answer", ja: "1問回答をシミュレート", ko: "답변 1개 시뮬레이션" }
};
document.querySelectorAll(".lang-btn").forEach(btn => {
btn.addEventListener("click", () => applyLanguage(btn.dataset.lang));
});
function applyLanguage(lang) {
document.querySelectorAll("[data-i18n]").forEach(el => {
const entry = i18n[el.dataset.i18n];
if (entry && entry[lang]) el.innerText = entry[lang];
});
document.querySelectorAll(".lang-btn").forEach(b => b.classList.toggle("active", b.dataset.lang === lang));
}
// Day 21:心電圖波形
const canvas = document.getElementById("ecg");
const ctx2d = canvas.getContext("2d");
const beatPattern = [
{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 12, y: -3 }, { x: 14, y: 22 },
{ x: 16, y: -10 }, { x: 18, y: 0 }, { x: 22, y: -4 }, { x: 26, y: 0 }, { x: 40, y: 0 }
];
const beatWidth = 40;
const TOTAL_QUESTIONS = 5;
let answered = 0;
let offset = 0;
function getProgress() { return answered / TOTAL_QUESTIONS; }
function sampleBeat(x) {
const localX = x % beatWidth;
for (let i = 0; i < beatPattern.length - 1; i++) {
const a = beatPattern[i], b = beatPattern[i + 1];
if (localX >= a.x && localX <= b.x) {
const t = (localX - a.x) / (b.x - a.x || 1);
return a.y + (b.y - a.y) * t;
}
}
return 0;
}
function draw() {
ctx2d.clearRect(0, 0, canvas.width, canvas.height);
const midY = canvas.height / 2;
const amplitude = 1 + getProgress() * 1.5;
const speed = 2 + getProgress() * 6;
ctx2d.beginPath();
ctx2d.strokeStyle = "#4ade80";
ctx2d.lineWidth = 2;
for (let x = 0; x < canvas.width; x++) {
const y = midY - sampleBeat(x + offset) * amplitude;
if (x === 0) ctx2d.moveTo(x, y); else ctx2d.lineTo(x, y);
}
ctx2d.stroke();
offset += speed;
requestAnimationFrame(draw);
}
function nextQuestion() {
if (answered >= TOTAL_QUESTIONS) return;
answered++;
document.getElementById("progressFill").style.width = (getProgress() * 100) + "%";
}
applyLanguage("zh");
draw();
</script>
</body>
</html>
再存成 .html 打開,先換幾次語言,再點聲音按鈕,接著連點幾下「模擬作答一題」,看心跳線怎麼隨著進度拉高振幅。三個原本各自獨立的小東西,擺進同一頁卻沒有互相扯後腿,這點我自己測的時候還真有點意外。
1. 三樣東西合體,比想像中省力,但不是完全沒代價。
音效引擎跟心電圖幾乎是原封不動搬過來就能用,因為它們本來就各管各的狀態,互不干涉。真正要盯著的是 i18n:applyLanguage() 掃的是當下頁面上存在的 data-i18n 元素,如果某個區塊的文字是程式稍後才動態塞進去的,就得確保插入時機排在掃描之後,不然會被漏翻。今天這份拼盤因為文字一開始就寫死在 HTML 裡,沒撞到這顆雷,但往後想接更多動態區塊,這點不能忘。
2. 原來這幾篇是照著「輸入、處理、輸出」的順序在走,雖然寫的時候完全沒這樣規劃過。
音效跟 i18n 比較像「輸出層」:使用者做了什麼,就用聲音或文字回應他。計分跟雷達圖那票算「處理層」:把原始資料擰成有意義的東西。求籤、戳泡泡那幾篇則是「輸入層」:給使用者一個能動手的理由。回頭看才發現,這個專題不知不覺走出了一個簡化版的系統架構。
3. 「拿一個比例去驅動某個看得見的東西」這招,在這十四篇裡至少用了四次。
雷達圖的長度、耗損率的百分比、心電圖的振幅跟速度,講到底都是同一句話:算出一個落在 0 到 1 之間的數字,拿它去開一個視覺效果的大小。這個招式反覆出現,我卻每次都重新想一遍邏輯,從來沒把它抽成共用的東西。
坐下來寫這篇之前,我原本以為自己會交出一篇流水帳,一天一條,照順序條列過去。
結果真正動手整理,才發現這些看似各自獨立的小功能,背後一直有一條沒被明說的線。不是我事先畫好的架構圖,而是寫著寫著,自然而然用類似的思路在解每一題:抓出一個比例、拿它驅動一個效果、再補一句聲音或文字當回饋。
這讓我想到,寫三十天的文章跟寫三十天的程式,其實是同一種修煉。一開始每天只想著「今天要做什麼新東西」,走到第二十二天回頭看,反而照出了自己沒察覺的習慣跟偏好。
也順便揭穿一件有點尷尬的事:normalize() 的換算公式,跟今天心電圖裡算振幅的邏輯,骨子裡根本是同一行數學,卻散落在不同天的程式碼裡,各自重寫了一遍。這些重複現在看起來有點刺眼。
今天沒有新功能,只是把第二階段這十四天走過的路盤點一遍,順便把音效、i18n、心電圖三樣東西塞進同一頁,證明它們真的能一起運作,不只是各自孤立的展示品。
整理的過程也挖出一個不太舒服的事實:至少四個地方都在做「算比例、換算百分比」這件事,每次都重新手刻一遍,完全沒有共用。
雷達圖、耗損率、六維度歸一化、心電圖的振幅,骨子裡是同一條公式,卻散落在四個不同的檔案裡,想改一處都得翻好幾篇文章才找得到源頭。
所以明天 Day 23,在正式邁入下一個階段之前,我想先清掉這筆債:把重複出現的換算公式,還有音效合成引擎,整理成真正共用的工具函式,之後不管哪個功能要用到「比例轉換」,都能直接呼叫同一個地方,不用每次重新發明輪子。
把這些年埋下的雷,稍微清一清。明天見!